Skip to content

refactor: migrate from ts-node to native Node ESM#5265

Merged
lukemelia merged 38 commits into
mainfrom
cs-11449-esm-migration-wip
Jun 20, 2026
Merged

refactor: migrate from ts-node to native Node ESM#5265
lukemelia merged 38 commits into
mainfrom
cs-11449-esm-migration-wip

Conversation

@lukemelia

Copy link
Copy Markdown
Contributor

Summary

Migrates the node-run package cluster off ts-node and onto native Node (≥24) TypeScript execution (type-stripping): runtime-common, postgres, billing, realm-server, realm-test-harness, ai-bot, bot-runner, software-factory, and matrix.

All service entry points load under native node, the qunit test suites run via node tests/index.ts, and lint:types is green across all nine cluster packages.

Linear: CS-11449

Warning

Draft. The realm-server full integration suite still needs to run against the dev-services stack in CI, and host lint:types wants CI confirmation (configured the same as the now-green catalog). See "Remaining" below.

What changed

Runtime

  • ts-node --transpileOnly <entry>node <entry>.ts across package.json scripts, shell scripts, mise-tasks, and in-source spawn(...) sites; removed the ts-node devDependency.
  • Added exports maps + "type":"module" to the cluster packages so native Node can resolve their .ts entry points.
  • Switched lodashlodash-es repo-wide (native ESM named imports; tree-shakeable under Vite too).
  • Resolved native-ESM strictness gaps the bundler/ts-node masked: explicit relative-import extensions, CJS default/named interop (fs-extra, debug, jsonwebtoken, node-pg-migrate), __dirname/__filenameimport.meta, and createRequire shims for lazy require().

Test runners

  • Each cluster test package runs node tests/index.ts with a qunit-bootstrap (autostart off, TAP reporter, failure-based exit code) replacing the qunit CLI + ts-node/register.

Type-checking (lint:types)

  • Stayed on nodenext (models the native-node runtime rather than masking it like bundler would).
  • Expanded the base-realm path alias to resolve .gts/.ts under ESM-mode resolution; this also clears the transitive cascade in consumers.
  • Bumped magic-string 0.25.9 → ^0.30.21 (properly packaged); AMD transpiler output verified unchanged.

Tooling

  • Added a reusable, idempotent codemod under scripts/esm-codemod/ (run.mjs + per-rule modules) and a README.md documenting the full error taxonomy and the manual fixes.

Verification

  • All nine cluster packages: lint:types clean (0 errors).
  • Suites under native node: ai-bot 170/170, bot-runner 38 pass + 1 skip, software-factory 452/453 (the one failure is a macOS-vs-Linux dual-stack socket test, not a migration regression).
  • All service entries (realm-server main/worker/worker-manager/prerender/manager, ai-bot, bot-runner) link their full module graph under native node.

Remaining before un-drafting

  • realm-server full integration suite needs the dev-services stack (CI).
  • host lint:types CI confirmation.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Preview deployments

Host Test Results

    1 files  +    1      1 suites  +1   1h 55m 1s ⏱️ + 1h 55m 1s
3 136 tests +3 136  3 121 ✅ +3 121  15 💤 +15  0 ❌ ±0 
3 155 runs  +3 155  3 140 ✅ +3 140  15 💤 +15  0 ❌ ±0 

Results for commit 9c55e1a. ± Comparison against earlier commit 0cbf225.

Realm Server Test Results

    1 files  ±0      1 suites  ±0   12m 12s ⏱️ +26s
1 740 tests ±0  1 740 ✅ ±0  0 💤 ±0  0 ❌ ±0 
1 833 runs  ±0  1 833 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit 9c55e1a. ± Comparison against earlier commit 0cbf225.

@lukemelia lukemelia force-pushed the cs-11449-esm-migration-wip branch from 7651d64 to 5acc18b Compare June 17, 2026 20:01
@lukemelia lukemelia changed the title refactor: migrate node-run cluster from ts-node to native Node ESM refactor: migrate from ts-node to native Node ESM Jun 19, 2026
@lukemelia lukemelia marked this pull request as ready for review June 19, 2026 02:36
@lukemelia lukemelia requested review from a team and habdelra June 19, 2026 02:36
@habdelra habdelra requested a review from Copilot June 19, 2026 12:51

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

@lukemelia lukemelia force-pushed the cs-11449-esm-migration-wip branch from 0e42e47 to 0cbf225 Compare June 20, 2026 02:26
lukemelia and others added 20 commits June 19, 2026 22:53
Native Node 24 type-stripping is the target runtime for the ts-node
migration. Set engines.node to ">=24" at the root and align packages
that still declared older floors (boxel-cli was ">= 18"; host and
eslint-plugin-boxel were ">= 20"). Add a root .nvmrc matching the
existing .mise.toml pin (24.13.1), and bump the one stray CI
setup-node from 20 to 24.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Native Node ignores ts-node's resolver: it reads package.json exports
and will neither fall back to index.js nor extension-resolve bare
specifiers. Add an exports map pointing at the .ts sources so both
native Node and the host's Vite/Embroider build resolve the package
and its subpaths (bare entry, explicit directory-index subpaths, and a
"./*" -> "./*.ts" wildcard); keep the existing #fetch/#lint-task
imports. Do not set "type":"module" — the package ships CJS .js eslint
configs that would break, so Node's per-file syntax detection handles
the mix.

Replace the runtime constructs native Node rejects under ESM: the lazy
require() in fetch-node.ts (now createRequire) and url-signature.ts
(now a bare crypto import, which the host already aliases to
crypto-browserify), and __dirname -> import.meta.dirname. Flip the
bench:amd scripts and their dynamic requires to native node, drop the
ts-node devDependency, and drop the now-redundant .ts extension on the
marked-sync shim import in the host so it resolves through the exports
map.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e ESM

The host's Vite/Embroider build adds extensions and index-resolves, so it
hid import forms that native Node's ESM resolver rejects. Make every
import in runtime-common resolvable by native Node (verified by importing
the package and all subpaths under `node`, no bundler):

- Add `.js` to extensionless CJS-package subpath imports that have no
  exports map (lodash/<method>, matrix-js-sdk/lib/utils — the latter has
  both a utils.js file and a utils/ directory, and ESM picks the dir).
- Replace the named import from lodash's CJS root in catalog.ts with
  per-method default imports (Node can't statically detect lodash's CJS
  named exports).
- Point the three bare-directory barrel imports (`from '.'`) at
  `./index.ts` (native ESM does not index-resolve directories).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
postgres is the first leaf consumer of runtime-common to run on native
Node. Add an exports map pointing at its .ts sources (".", "./*", and
"./package.json") so consumers resolve the package and its subpaths
without ts-node; replace __dirname with import.meta.dirname in
pg-adapter.ts and convert-to-sqlite.ts; flip the migrate and make-schema
scripts from ts-node to native node; and drop the ts-node dependency.
Leave "type" unset so Node's syntax detection keeps the package's CJS
.js migration helpers working alongside the ESM .ts sources.

Verified by importing @cardstack/postgres (and its runtime-common
dependency chain) under native node.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Foundation toward the full node-run ESM migration: type:module on
runtime-common + postgres, nested package.json {type:commonjs} for their
CJS dirs (etc/eslint, migrations), .cjs renames for stray CJS files, and
revert of the collateral host/eslint-plugin engines bumps. Resolves
TS1470 but TS2307 (base path aliases) and TS2349 (CJS interop) remain,
plus all consumers still need migrating. Parked — not green.
Swap ts-node for native Node (>=24) TypeScript execution across the
node-run package cluster: runtime-common, postgres, billing,
realm-server, realm-test-harness, ai-bot, bot-runner, matrix, and
software-factory. All five service entries plus ai-bot and bot-runner
now link their full module graph under native node.

Native Node ESM is stricter than ts-node/Vite about resolution; the
recurring breakages and their fixes are captured as a reusable,
idempotent codemod under scripts/esm-codemod/ (run.mjs orchestrator,
per-rule modules, and README.md documenting the full error taxonomy):

- add explicit extensions to relative imports (no extension search)
- switch lodash -> lodash-es named imports repo-wide
- CJS named imports (fs-extra, debug) -> default + destructure
- __dirname/__filename -> import.meta.dirname/filename
- ts-node --transpileOnly <entry> -> node <entry>.ts in
  package.json / *.sh / mise-tasks

Manual fixes not suited to a blind codemod: exports maps + type:module
on billing and realm-test-harness; in-source spawn('ts-node', ...) ->
spawn('node', ['x.ts', ...]); postgres .js->.cjs import; matrix-js-sdk
deep-import .js suffixes; and an import.meta.url-as-path bug in lint.ts.

Still remaining: the qunit test-runner bootstrap (qunit --require
ts-node/register -> node tests/index.ts) and removing the ts-node
devDependency afterward.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace `qunit --require ts-node/register/transpile-only tests/index.ts`
with `node tests/index.ts` for ai-bot, bot-runner, software-factory, and
realm-server. The qunit CLI used to supply the QUnit global, TAP reporter,
autostart, and a failure-based exit code; each package now has a
tests/qunit-bootstrap.ts that wires those up (autostart off, TAP reporter,
runEnd exit code) and its tests/index.ts ends with QUnit.start().

Test files imported `{ module, test }` from qunit, which Node's ESM loader
can't read as named exports from the CJS package; the cjs-named-to-default
codemod (now line-anchored so it skips qunit imports embedded in
test-fixture strings) rewrites them to a default import + destructure.
Also adds jsonwebtoken to that codemod's CJS list.

realm-server's tests/index.ts additionally needed a createRequire shim so
its synchronous, order-preserving test-file loader keeps working, with an
explicit `.ts` on each require (native require does no extension search);
its CI JUnit reporter is renamed .cjs (realm-server is now type:module).

Removes the now-unused ts-node devDependency from every package and fixes
two `#!/usr/bin/env ts-node` shebangs and a `ps | grep ts-node`
process-matcher that no longer matches the node-run children.

Known remaining: ai-bot has 5 tests that reassign sealed ES-module
namespace bindings for mocking (needs DI); software-factory's full node
suite needs boxel-cli and its own src migrated off bare require().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two ESM-semantic issues surfaced once ai-bot ran under `node tests/index.ts`:

1. node-pg-migrate default-export interop (postgres). `import migrate from
   'node-pg-migrate'` binds the whole CJS namespace under native ESM, so the
   runner (on `.default`) wasn't callable — "migrate is not a function". This
   broke DB migration everywhere, not just tests. Resolve `.default ?? ns`. Also
   add `package.json` to the migrate `ignorePattern` so the `type:commonjs`
   marker file in the migrations dir isn't loaded as a migration.

2. Sentry mocking against a sealed ES-module namespace. Tests reassigned
   `(Sentry as any).captureException`, a no-op because `import * as Sentry`
   bindings are read-only. Route the source through a mutable `lib/sentry.ts`
   `errorReporter` indirection that responder.ts/debug.ts call and the tests
   stub.

ai-bot now passes 170/170 (locking tests need the usual PG env vars). Documents
both as error classes #12 and #13 in the codemod README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
software-factory's node test suite crashed at boot on `require('@cardstack/logger')`
in src/logger.ts — `require` doesn't exist in ESM scope. @cardstack/logger is a
CJS package that ships no type declarations, so the existing code cast the
`require()` result; keep that by defining `require` via createRequire rather than
switching to a static import (which would trip TS7016). Same fix for the
identical realm-test-harness/src/logger.ts.

With this and the previously-added @cardstack/boxel-cli `exports` map, the
software-factory node suite runs: 452/453 pass. The one failure
(port-allocator dual-stack `::` bind) is a macOS-vs-Linux socket-semantics
difference, not a migration regression — it asserts Linux behavior and fails the
same way under ts-node on macOS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Packages flipped to "type":"module" (billing, realm-server, realm-test-harness)
treat their `.eslintrc.js` as ESM, so ESLint fails to load them
("module is not defined in ES module scope"). Rename those configs to
`.eslintrc.cjs`, which ESLint still auto-discovers.

Same problem for realm-server's CJS helper scripts run via `node`:
`shard-test-modules.js` (invoked in CI to compute test shards) and
`run-test-modules.js` both use `require`. Rename to `.cjs` and update the CI
workflow reference.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…odemod README

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
setup-localhost-resolver.ts (undici/dns, gated on BOXEL_ENVIRONMENT) and
lib/wtfnode-on-signal.ts (wtfnode, gated on BOXEL_WTFNODE) call `require`,
which doesn't exist in ESM scope — they'd throw the moment their gate is set.
Recreate `require` via createRequire inside each guard, preserving the lazy,
optional-dependency semantics (matching the existing fetch-node.ts pattern).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Flipping the node cluster to "type":"module" switches ember-tsc's nodenext
resolution from CJS-mode to ESM-mode, which surfaced the parked-WIP type errors.
Root causes and fixes:

- Base-realm path alias (the bulk): under ESM-mode nodenext the extensionless
  `"https://cardstack.com/base/*": ["../base/*"]` mapping no longer resolves
  (.gts/.ts need explicit extensions). Expand every tsconfig's mapping to try
  `*.gts`/`*.ts`/`*.d.ts`/`*`. This also clears the transitive TS2307 cascade in
  consumers (catalog, host, …) that type-check runtime-common's now-ESM sources.
- CJS default-interop the nodenext type layer can't model even though it works at
  runtime: bump magic-string 0.25.9 -> ^0.30.21 (properly packaged exports+types;
  AMD transpiler output verified unchanged); re-export `ignore` once with its call
  signature (runtime-common/ignore.ts); type node-pg-migrate's runner via
  RunnerOption.
- Make ai-bot/bot-runner/software-factory/matrix "type":"module" (they already run
  as ESM and use import.meta, which CJS-mode rejected with TS1470); switch matrix's
  tsconfig to nodenext + skipLibCheck; rename their CJS .eslintrc.js -> .cjs.
- Misc: `import type` from a bare `utils/jwt` -> relative; matrix-js-sdk type
  deep-import gains .js; QUnit.reporters/on cast (missing from @types/qunit);
  drop a now-conflicting `declare const QUnit`; sweep 3 catalog cards to lodash-es.

All nine node-cluster packages now type-check clean (0 errors): runtime-common,
postgres, billing, realm-server, realm-test-harness, ai-bot, bot-runner,
software-factory, matrix. Suites still pass (ai-bot 170, bot-runner 38) and the
service entries still load.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…od README

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI surfaced two gaps from the lodash-es switch and the require shims:

- base, boxel-ui, and experiments-realm import lodash-es but never declared it
  (the dep swap only covered packages that previously declared `lodash`), so
  their builds/type-checks failed to resolve the module. Add `lodash-es`
  (boxel-ui also swaps `@types/lodash` -> `@types/lodash-es`).
- realm-server lint:js: prettier-wrap the multi-line CJS destructures the
  interop codemod emitted, and drop the now-unused
  `eslint-disable @typescript-eslint/no-var-requires` directives left above the
  createRequire-shimmed `require()` calls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two more CI failures from the native-ESM move:

- Build / Realm Performance Benchmark: the host build's getLatestSchemaFile()
  (config/environment.js) picked the lexically-last entry in the migrations and
  schema dirs. The migrations dir now contains `package.json` (pins it to
  type:commonjs), which sorts after the timestamped migrations and made the
  freshness check compare `package.json` against the schema timestamp → false
  "schema out of date". Filter both lists to timestamped files only.
- AMD Transpile Bench: `if (require.main === module)` entry-point guards throw
  `require is not defined` under ESM. Replace with `import.meta.main` (Node 24)
  across the affected scripts in runtime-common, realm-server, matrix, and
  boxel-cli.

AMD bench gate passes locally (also confirms magic-string 0.30 transpile output
is within the perf tolerance).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…uild

boxel-cli is a published CJS package (esbuild emits format:cjs), but the
ts-node->node conversion of its dev/build scripts made node run them as ESM
(import syntax + type:unset), so their `__dirname`/`require.main` broke:

- Convert the build scripts (build.ts, build-types.ts, build-test-harness.ts,
  build-realms.ts, build-skills.ts, build-plugin.ts) to import.meta.dirname /
  import.meta.main. Leave src/ on __dirname — esbuild bundles it to CJS, where
  __dirname is correct and import.meta would be empty.
- tsconfig -> module:esnext + moduleResolution:bundler so the now-ESM scripts'
  import.meta type-checks (the package stays CJS-published; tsconfig only drives
  lint:types, esbuild owns the build).
- `start` now runs the built dist (node dist/index.js) since src is no longer
  directly node-runnable as ESM.

Also fix runtime-common/ignore.ts to import the `ignore` CJS package via a
namespace (`.default ?? ns`) instead of a default import: esbuild bundling
downstream consumers (vscode-boxel-tools) couldn't synthesize the default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…r boot

The ts-node->node codemod's entry capture grabbed the closing `)` of a
`$(ts-node … secret)` command substitution and appended `.ts` after it:

  MATRIX_REGISTRATION_SHARED_SECRET=$(node "…/matrix-registration-secret.ts").ts

so the secret value was suffixed with a literal `.ts`. The realm-server then
failed to come up, never bound :4201, and every integration suite that needs the
dev-services stack (Host / Realm-Server / Matrix / Live Tests and downstream)
cascaded. Verified locally: with the secret restored, the realm-server starts
under native node and serves :4201 (base/skills modules + indexing).

Fix the three mise service tasks (realm-server, test-realms, realm-server-base)
and harden the codemod's entry pattern to exclude shell metacharacters
( ) ; & | ) so a command substitution can't be mis-captured again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three native-ESM gaps the CI integration stack surfaced, each cascading widely:

- handlers/handle-indexing-dashboard.ts used `require.resolve('morphdom/…')` at
  module load. `require.resolve` (no `require(` token, no `require.main`) slipped
  past the earlier sweeps; it threw `require is not defined` and crashed the
  worker-manager on import -> no indexing -> realm-server never reached :4201
  readiness. Resolve via createRequire.
- matrix synapse start (support/synapse/index.ts, support/docker.ts) and
  realm-server utils/jwt.ts imported CJS packages as `import * as fse/jsonwebtoken`
  and then read members off the namespace. Under native ESM those members live on
  the CJS default, so `fse.stat`/`sign`/`verify` were `undefined` — synapse failed
  to start (`fse.stat is not a function`) and JWT signing/verification would no-op.
  Switch to default imports.

Verified each member resolves under native node; matrix and realm-server
type-check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lukemelia and others added 18 commits June 19, 2026 22:53
The ESM migration switched realm-served code to import from lodash-es,
but the VirtualNetwork shim was still registered under the old 'lodash'
id. Card code importing lodash-es found no shim and fell through to a
network fetch (https://packages/lodash-es), failing every prerender
render and wedging the base realm readiness check.

Update the shim id to lodash-es and the matching expected-dependency
lists in the realm indexing test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The migrations dir now carries a package.json pinning it to
type:commonjs (so the CJS migration files keep working under the
package's new type:module). node-pg-migrate's CLI default ignore
pattern only skips dotfiles, so `pnpm migrate` loaded that package.json
as a migration named "package" with no up/down function and threw
"Unknown value for direction: up".

Pass the same --ignore-pattern the programmatic runner in pg-adapter.ts
already uses, so the CLI path (DB setup, CI migrate steps) skips
package.json and .eslintrc.js too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The migrations dir carries a package.json pinning it to type:commonjs
(so the CJS migration files keep working under the package's new
type:module). node-pg-migrate's CLI default ignore pattern only skips
dotfiles, so the test-pg seed builder loaded that package.json as a
migration named "package" with no up/down function and threw "Unknown
value for direction: up", failing realm-server DB setup in CI.

4ae9312 fixed the `pnpm migrate` package.json script the same way, but
the create_seeded_db.sh seed builder has its own inlined node-pg-migrate
invocation that was missed. Pass the same --ignore-pattern so the seed
path skips package.json and .eslintrc.js too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`import * as QUnit from 'qunit'` yields an ESM namespace whose `.assert`
is undefined under native Node ESM (qunit is CJS; the real object is the
default export). ts-node's esModuleInterop previously masked this. The
helper's `QUnit.assert.codeEqual = codeEqual` then threw "Cannot set
properties of undefined (setting 'codeEqual')" at import time, aborting
the entire realm-server test suite.

Switch to the default import (`import QUnit from 'qunit'`) — the form
every realm-server test file already uses — so `.assert` resolves.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`import QUnit, { module, test } from 'qunit'` fails under native Node
ESM with "The requested module 'qunit' does not provide an export named
'module'" — qunit is CJS and exposes module/test as properties of the
default export, not as ESM named exports. Because tests/index.ts
requires every test file at load time (TEST_MODULES only filters which
run), this one file aborted all six realm-server shards.

Destructure module/test from the default import, matching the 142 other
realm-server test files that already use `import QUnit from 'qunit'`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The realm-watch-stop integration fixture spawned a child that called
`require('ts-node').register(...)` to load watch-process-registry.ts
from source. ts-node was removed in the ESM migration, so the child
threw "Cannot find module 'ts-node'", exited 1, and both
realm-watch-stop tests failed with "fake watcher exited early".

Node 24 strips TypeScript types natively, so the registry .ts loads via
a plain require with no loader. Verified the fixture boots
(FAKE_WATCHER_READY, registers, stays alive) and all three tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ts-node was dropped from every package's deps, but invocations, a broken
process-kill pattern, and stale comments/docs lingered. Sweep them:

Functional
- boxel-cli `bin/boxel.js`: the dev fallback `require('ts-node')` is gone;
  the CLI ships as a built CJS bundle (source uses CommonJS-only globals
  like __dirname and isn't native-ESM-runnable), so error clearly when
  dist is missing instead of throwing "Cannot find module 'ts-node'".
- `mise-tasks/lib/dev-common.sh`: the orphan-sweep matched
  `node_modules.*--transpileOnly (worker|main|prerender)`, which never
  matches the native `node main.ts` / `node worker-manager.ts` /
  `node prerender/*-server.ts` processes — so `mise run kill-all` and dev
  cleanup leaked the realm/worker/prerender ports. Match the native entries
  (by name + a distinctive flag) instead.
- `packages/postgres/Dockerfile`: ts-node → node for fix-migration-names.ts.
- `.github/workflows/boxel-cli-publish.yml`: ts-node → node (3 sites).
- delete `packages/realm-server/scripts/run-test-modules.cjs` — an unused
  qunit runner that still bootstrapped ts-node (the suite runs via
  run-qunit-with-test-pg.sh / `node tests/index.ts`).

Docs / comments / log strings
- start-*.sh: "about to exec ts-node" log lines now say node; header
  comments drop the ts-node tsconfig/binary rationale.
- mise service comments, worker-manager.ts, opencode.ts, find-package-root.ts,
  fixtures.ts, context-search + canusetool usage strings, boxel-cli + ai-bot
  READMEs, and the indexing-diagnostics skill: ts-node → node.

Left intentionally: the esm-codemod tooling, accurate "without ts-node"
notes, pg-adapter's historical esModuleInterop rationale, and the
phase-1-plan historical design doc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to the ts-node cleanup: the last references were explanatory
comments/docs, not usage. Reword them to describe the native-Node
behavior directly instead of contrasting with ts-node.

- .eslintrc.js / erasable-syntax-selectors.cjs: describe the
  "erasable TS for --experimental-strip-types" rule without naming ts-node.
- pg-adapter.ts: the node-pg-migrate `.default` unwrap is still required
  (native ESM binds the CJS namespace object; the runner is on `.default`)
  — keep the cast, but explain it purely in native-ESM terms.
- fake-watcher fixture + software-factory phase-1 plan: native `node`
  wording, no ts-node.

Only scripts/esm-codemod/** still mentions ts-node, by design (it's the
migration tooling).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t tests)

The two `/_post-deployment` maintenance-endpoints tests stubbed
`compareCurrentBoxelUIChecksum` / `writeCurrentBoxelUIChecksum` on the
`import * as` namespace of boxel-ui-change-checker. Under native ESM that
namespace is read-only, so sinon threw "ES Modules cannot be stubbed" and
both tests failed (the only red in realm-server shard 4).

Group the two functions on an exported mutable object `boxelUIChecker` and
have the post-deployment handler call through it; the tests stub the
object's methods. This matches the codebase's stubbing convention (stub
methods on objects/instances — global.fetch, stripe.subscriptions — never
module namespaces) and preserves the handler's behavior. Verified the stub
intercepts calls through the object under native node.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Env-mode CI (host shards, Live Tests, Software Factory) drives the
prerender through puppeteer, which in env-mode relies on puppeteer's
bundled Chrome (env-vars.sh only points PUPPETEER_EXECUTABLE_PATH at a
system Chrome in non-env-mode). actions/cache caches the pnpm store but
not ~/.cache/puppeteer, and on a store cache hit puppeteer's postinstall
skips the Chrome download — so the prerender threw "Could not find Chrome
(148.0.7778.97)", the standby pool stayed empty, base indexing never
finished, and /base/_readiness-check hung (host shards timed out at
000000). Main only passed because its warm cache still had Chrome.

- init: explicitly `puppeteer browsers install chrome` after install
  (idempotent; no-op when already cached) so every job that runs the
  prerender has Chrome regardless of cache state.
- Software Factory: `playwright install --with-deps` so the apt system
  libraries are present too ("Host system is missing dependencies to run
  browsers").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`pnpm --filter <pkg> exec puppeteer browsers install chrome` went through
pnpm's recursive-exec path and dropped the `chrome` arg in CI (puppeteer
printed its usage and exited 1), which failed the init step for every
workflow. Use a non-recursive `pnpm exec` from the package directory
instead — the same form SF's `pnpm exec playwright install` uses, which
forwards args correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
puppeteer's postinstall leaves an empty browser folder
(~/.cache/puppeteer/chrome/linux-<ver>) with the download skipped, so the
prerender fails "Could not find Chrome" and a plain `browsers install`
refuses with "folder exists but executable is missing — All providers
failed". Remove any partial install first, then download cleanly (the CDN
works — main's postinstall downloads the same version fine).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Abandon the bundled-Chrome approach for env-mode CI — puppeteer's
postinstall leaves an empty cache folder and an explicit `browsers
install` wouldn't reliably land Chrome where the prerender looks, so the
env-mode prerender kept failing "Could not find Chrome".

env-vars.sh already points PUPPETEER_EXECUTABLE_PATH at the system Chrome
in standard mode; the block was just inside the non-env-mode guard. Move
it to the "regardless of env-mode" section so env-mode uses the runner's
/usr/bin/google-chrome too (it exists — the non-env-mode jobs already use
it). Gated on the binary existing and the path being unset, so it's a
no-op-or-correct in env-mode prod (the prerender container installs
google-chrome-stable, and an explicit override still wins). Revert the
init browser-install step it replaces.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…l module

@cardstack/boxel-cli/api mapped to raw api.ts source. That resolves under
some loaders (vitest, plain node via the workspace symlink realpath) but not
Playwright's ESM worker loader, which failed loading software-factory's
.spec.ts files with "does not provide an export named 'BoxelCLIClient'",
taking down every SF Playwright shard.

Bundle api.ts to dist/api.js (esbuild CJS — named exports stay detectable by
Node's cjs-module-lexer, so ESM consumers keep `import { BoxelCLIClient }`)
and point exports["./api"] at it; the types condition still resolves to
api.ts, so type resolution is unchanged and no .d.ts is needed. Add a
`build:api` script and run it before software-factory's node + playwright
tests so the built module is present (only SF consumes this subpath).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`pnpm --filter @cardstack/boxel-cli build:api` runs only buildAPI() to
rebuild the API surface quickly for cross-package test consumers
(software-factory). But the bundled dist/api.js imports content-tag
transitively, and content-tag reads its wasm with
`${import.meta.dirname}/content_tag_bg.wasm` — i.e. from boxel-cli's
dist/. The wasm copy lived inside buildCLI() and only ran on a full
build, so software-factory's `test:node` / `test:playwright` saw
ENOENT on dist/content_tag_bg.wasm.

Hoist the copy into a standalone step that always runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same root cause as eb42f81 / 40ad5cf — qunit is CJS and exposes
module/test as properties of the default export, not as ESM named
exports. Under native Node ESM, `import { module, test } from 'qunit'`
throws "does not provide an export named 'module'". Because
tests/index.ts requires every test file at load time (TEST_MODULES
filters which run), this one file aborted all six realm-server shards.

Match the convention used by the other realm-server test files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Native Node ESM doesn't expose `__filename`. The sibling realm-server
tests use `basename(import.meta.filename)` for the same module label;
match that convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After rebasing onto main (which bumped puppeteer to 25.0.2 and removed
ts-node from realm-test-harness), `pnpm install` strips the residual
ts-node entries that lingered in pnpm-lock.yaml from the pre-rebase
state. No package.json changes — just lockfile alignment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@lukemelia lukemelia force-pushed the cs-11449-esm-migration-wip branch from 0cbf225 to 9c55e1a Compare June 20, 2026 02:54
@lukemelia lukemelia merged commit 0115a77 into main Jun 20, 2026
81 of 82 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants